forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { getQuery } from 'h3'
2import * as v from 'valibot'
3import { hash } from 'ohash'
4import type { VersionDistributionResponse } from '#shared/types'
5import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'
6import { groupVersionDownloads } from '#server/utils/version-downloads'
7
8/**
9 * Raw response from npm downloads API
10 * GET https://api.npmjs.org/versions/{package}/last-week
11 */
12interface NpmVersionDownloadsResponse {
13 package: string
14 downloads: Record<string, number>
15}
16
17/**
18 * Query parameter validation schema
19 */
20const QuerySchema = v.object({
21 mode: v.optional(v.picklist(['major', 'minor'] as const), 'major'),
22 filterThreshold: v.optional(
23 v.pipe(
24 v.string(),
25 v.toNumber(), // Fails validation on invalid conversion (e.g., "abc") instead of producing NaN
26 v.minValue(0), // Ensure non-negative values
27 ),
28 ),
29 filterOldVersions: v.optional(v.picklist(['true', 'false'] as const), 'false'),
30})
31
32/**
33 * GET /api/registry/downloads/:name/versions or /api/registry/downloads/@scope/name/versions
34 *
35 * Fetch per-version download statistics and group by major or minor version.
36 * Data is cached for 1 hour with stale-while-revalidate.
37 *
38 * Query parameters:
39 * - mode: 'major' | 'minor' (default: 'major')
40 * - filterThreshold: minimum percentage to include (default: 1)
41 * - filterOldVersions: 'true' to include only versions published in last year (default: 'false')
42 */
43export default defineCachedEventHandler(
44 async event => {
45 // Supports: /downloads/lodash/versions, /downloads/@scope/name/versions
46 const slugParam = getRouterParam(event, 'slug')
47 const pkgParamSegments = slugParam?.split('/') ?? []
48
49 const lastSegment = pkgParamSegments.at(-1)
50 if (!lastSegment || lastSegment !== 'versions') {
51 throw createError({
52 statusCode: 404,
53 message: 'Invalid endpoint. Expected /versions',
54 })
55 }
56
57 const segments = pkgParamSegments.slice(0, -1)
58
59 const { rawPackageName } = parsePackageParams(segments)
60
61 if (!rawPackageName) {
62 throw createError({
63 statusCode: 404,
64 message: 'Package name is required',
65 })
66 }
67
68 try {
69 const query = getQuery(event)
70 const parsed = v.parse(QuerySchema, query)
71 const mode = parsed.mode
72 const filterThreshold = parsed.filterThreshold ?? 1
73 const filterOldVersionsBool = parsed.filterOldVersions === 'true'
74
75 const url = `https://api.npmjs.org/versions/${rawPackageName}/last-week`
76 const npmResponse = await fetch(url)
77
78 if (!npmResponse.ok) {
79 if (npmResponse.status === 404) {
80 throw createError({
81 statusCode: 404,
82 message: 'Package not found',
83 })
84 }
85 throw createError({
86 statusCode: 502,
87 message: 'Failed to fetch version download data from npm API',
88 })
89 }
90
91 const data: NpmVersionDownloadsResponse = await npmResponse.json()
92
93 let groups = groupVersionDownloads(data.downloads, mode)
94
95 if (filterThreshold > 0) {
96 groups = groups.filter(group => group.percentage >= filterThreshold)
97 }
98
99 const totalDownloads = Object.values(data.downloads).reduce((sum, count) => sum + count, 0)
100
101 const apiResponse: VersionDistributionResponse = {
102 package: rawPackageName,
103 mode,
104 totalDownloads,
105 groups,
106 timestamp: new Date().toISOString(),
107 }
108
109 if (filterOldVersionsBool) {
110 try {
111 const oneYearAgo = new Date()
112 oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1)
113 const afterDate = oneYearAgo.toISOString()
114
115 // Decode package name in case it's URL-encoded (e.g., %40prisma%2Fclient -> @prisma/client)
116 const decodedPackageName = decodeURIComponent(rawPackageName)
117
118 // Fetch directly from npm-fast-meta HTTP API
119 const fastMetaUrl = `https://npm.antfu.dev/versions/${encodeURIComponent(decodedPackageName)}?after=${encodeURIComponent(afterDate)}`
120 const fastMetaResponse = await fetch(fastMetaUrl)
121
122 if (!fastMetaResponse.ok) {
123 throw new Error(`npm-fast-meta returned ${fastMetaResponse.status}`)
124 }
125
126 const versionData = (await fastMetaResponse.json()) as { versions: string[] }
127 apiResponse.recentVersions = versionData.versions
128 } catch {
129 // Graceful degradation - don't fail entire request if npm-fast-meta fails
130 }
131 }
132
133 return apiResponse
134 } catch (error: unknown) {
135 handleApiError(error, {
136 statusCode: 502,
137 message: 'Failed to fetch version download distribution',
138 })
139 }
140 },
141 {
142 maxAge: CACHE_MAX_AGE_ONE_HOUR,
143 swr: true,
144 getKey: event => {
145 const slug = getRouterParam(event, 'slug') ?? ''
146 const query = getQuery(event)
147 // Use ohash to create deterministic cache key from query params
148 // This ensures different param combinations = different cache entries
149 return `version-downloads:v5:${slug}:${hash(query)}`
150 },
151 },
152)